Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RSC: Support CJS 'use client' modules #10682

Merged
merged 7 commits into from
May 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion packages/vite/src/lib/registerFwGlobalsAndShims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,18 @@ function registerFwShims() {

globalThis.__webpack_chunk_load__ ||= async (id: string) => {
console.log('registerFwShims chunk load id', id)
return import(id).then((m) => globalThis.__rw_module_cache__.set(id, m))
return import(id).then((mod) => {
console.log('registerFwShims chunk load mod', mod)

// checking m.default to better support CJS. If it's an object, it's
// likely a CJS module. Otherwise it's probably an ES module with a
// default export
if (mod.default && typeof mod.default === 'object') {
return globalThis.__rw_module_cache__.set(id, mod.default)
}

return globalThis.__rw_module_cache__.set(id, mod)
})
}

globalThis.__webpack_require__ ||= (id: string) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import path from 'node:path'
import { vol } from 'memfs'
import { normalizePath } from 'vite'

import {
afterAll,
beforeAll,
describe,
it,
expect,
vi,
afterEach,
} from 'vitest'

import { rscTransformUseClientPlugin } from '../vite-plugin-rsc-transform-client'

vi.mock('fs', async () => ({ default: (await import('memfs')).fs }))

const RWJS_CWD = process.env.RWJS_CWD

beforeAll(() => {
// Add a toml entry for getPaths et al.
process.env.RWJS_CWD = '/Users/tobbe/rw-app/'
vol.fromJSON(
{
'redwood.toml': '',
},
process.env.RWJS_CWD,
)
})

afterAll(() => {
process.env.RWJS_CWD = RWJS_CWD
})

describe('rscRoutesAutoLoader', () => {
afterEach(() => {
vi.resetAllMocks()
})

it('should handle CJS modules with exports.Link = ...', async () => {
const id = normalizePath(
path.join(
process.env.RWJS_CWD,
'node_modules',
'@redwoodjs',
'router',
'dist',
'link.js',
),
)

const plugin = rscTransformUseClientPlugin({
'rsc-link.js-13': id,
})

if (typeof plugin.transform !== 'function') {
expect.fail('Expected plugin to have a transform function')
}

// Calling `bind` to please TS
// See https://stackoverflow.com/a/70463512/88106
const output = await plugin.transform.bind({})(
`"use strict";
'use client';

// This needs to be a client component because it uses onClick, and the onClick
// event handler can't be serialized when passed as an RSC Flight response
var _Object$defineProperty = require("@babel/runtime-corejs3/core-js/object/define-property");
var _interopRequireWildcard = require("@babel/runtime-corejs3/helpers/interopRequireWildcard").default;
_Object$defineProperty(exports, "__esModule", {
value: true
});
exports.Link = void 0;
var _react = _interopRequireWildcard(require("react"));
var _history = require("./history");
var _jsxRuntime = require("react/jsx-runtime");
const Link = exports.Link = /*#__PURE__*/(0, _react.forwardRef)((_ref, ref) => {
let {
to,
onClick,
...rest
} = _ref;
return /*#__PURE__*/(0, _jsxRuntime.jsx)("a", {
href: to,
ref: ref,
...rest,
onClick: event => {
if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return;
}
event.preventDefault();
if (onClick) {
const result = onClick(event);
if (typeof result !== 'boolean' || result) {
(0, _history.navigate)(to);
}
} else {
(0, _history.navigate)(to);
}
}
});
});
`,
id,
)

const clientId = normalizePath(
path.join(
process.env.RWJS_CWD,
'web',
'dist',
'rsc',
'assets',
'rsc-link.js-13.mjs',
),
)

// What we are interested in seeing here is:
// - There's a CLIENT_REFERENCE
// - There's a Link export
// - There's a proper $$id
expect(output)
.toMatchInlineSnapshot(`"const CLIENT_REFERENCE = Symbol.for('react.client.reference');
export const Link = Object.defineProperties(function() {throw new Error("Attempted to call Link() from the server but Link is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#Link"}});
"`)
})

it('should handle CJS modules with module.exports = { ErrorIcon, ToastBar, ... }', async () => {
const id = normalizePath(
path.join(
process.env.RWJS_CWD,
'node_modules',
'react-hot-toast',
'dist',
'index.js',
),
)

const plugin = rscTransformUseClientPlugin({
'rsc-index.js-15': id,
})

if (typeof plugin.transform !== 'function') {
expect.fail('Expected plugin to have a transform function')
}

// Calling `bind` to please TS
// See https://stackoverflow.com/a/70463512/88106
const output = await plugin.transform.bind({})(
`"use client";
"use strict";var Y=Object.create;var E=Object.defineProperty;var q=Object.getOwnPropertyDescriptor;var G=Object.getOwnPropertyNames;var K=Object.getPrototypeOf,Z=Object.prototype.hasOwnProperty;
var ee=(e,t)=>{for(var o in t)E(e,o,{get:t[o],enumerable:!0})},j=(e,t,o,s)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of G(t))!Z.call(e,r)&&r!==o&&E(e,r,{get:()=>t[r],enumerable:!(s=q(t,r))||s.enumerable});
return e};var W=(e,t,o)=>(o=e!=null?Y(K(e)):{},j(t||!e||!e.__esModule?E(o,"default",{value:e,enumerable:!0}):o,e)),te=e=>j(E({},"__esModule",{value:!0}),e);var Ve={};ee(Ve,{CheckmarkIcon:()=>F,ErrorIcon:()=>w,LoaderIcon:()=>M,ToastBar:()=>$,ToastIcon:()=>U,Toaster:()=>J,default:()=>_e,resolveValue:()=>u,toast:()=>n,useToaster:()=>V,useToasterStore:()=>_});
module.exports=te(Ve);var oe=e=>typeof e=="function",u=(e,t)=>oe(e)?e(t):e;var Q=(()=>{let e=0;return()=>(++e).toString()})(),R=(()=>{let e;return()=>{if(e===void 0&&typeof window<"u"){
let t=matchMedia("(prefers-reduced-motion: reduce)");e=!t||t.matches
}return e}})();var k=require("react"),re=20;var v=new Map,se=1e3,X=e=>{if(v.has(e))return;let t=setTimeout(()=>{v.delete(e),l({type:4,toastId:e})},se);
v.set(e,t)},ae=e=>{let t=v.get(e);t&&clearTimeout(t)},H=(e,t)=>{switch(t.type){case 0:return{...e,toasts:[t.toast,...e.toasts].slice(0,re)};case 1:return t.toast.id&&ae(t.toast.id),
{...e,toasts:e.toasts.map(a=>a.id===t.toast.id?{...a,...t.toast}:a)};case 2:let{toast:o}=t;return e.toasts.find(a=>a.id===o.id)?H(e,{type:1,toast:o}):H(e,{type:0,toast:o});
case 3:let{toastId:s}=t;return s?X(s):e.toasts.forEach(a=>{X(a.id)}),{...e,toasts:e.toasts.map(a=>a.id===s||s===void 0?{...a,visible:!1}:a)};
case 4:return t.toastId===void 0?{...e,toasts:[]}:{...e,toasts:e.toasts.filter(a=>a.id!==t.toastId)};case 5:return{...e,pausedAt:t.time};
case 6:let r=t.time-(e.pausedAt||0);return{...e,pausedAt:void 0,toasts:e.toasts.map(a=>({...a,pauseDuration:a.pauseDuration+r}))}}},I=[],D={toasts:[],pausedAt:void 0},l=e=>{D=H(D,e),I.forEach(t=>{t(D)})},ie={blank:4e3,error:4e3,success:2e3,loading:1/0,custom:4e3},_=(e={})=>{let[t,o]=(0,k.useState)(D);(0,k.useEffect)(()=>(I.push(o),()=>{let r=I.indexOf(o);r>-1&&I.splice(r,1)}),[t]);
let s=t.toasts.map(r=>{var a,c;return{...e,...e[r.type],...r,duration:r.duration||((a=e[r.type])==null?void 0:a.duration)||(e==null?void 0:e.duration)||ie[r.type],style:{...e.style,...(c=e[r.type])==null?void 0:c.style,...r.style}}});return{...t,toasts:s}};var ce=(e,t="blank",o)=>({createdAt:Date.now(),visible:!0,type:t,ariaProps:{role:"status","aria-live":"polite"},message:e,pauseDuration:0,...o,id:(o==null?void 0:o.id)||Q()}),S=e=>(t,o)=>{let s=ce(t,e,o);return l({type:2,toast:s}),s.id},n=(e,t)=>S("blank")(e,t);n.error=S("error");n.success=S("success");
n.loading=S("loading");n.custom=S("custom");n.dismiss=e=>{l({type:3,toastId:e})};n.remove=e=>l({type:4,toastId:e});n.promise=(e,t,o)=>{let s=n.loading(t.loading,{...o,...o==null?void 0:o.loading});
return e.then(r=>(n.success(u(t.success,r),{id:s,...o,...o==null?void 0:o.success}),r)).catch(r=>{n.error(u(t.error,r),{id:s,...o,...o==null?void 0:o.error})}),e};var A=require("react");var pe=(e,t)=>{l({type:1,toast:{id:e,height:t}})},de=()=>{l({type:5,time:Date.now()})},V=e=>{let{toasts:t,pausedAt:o}=_(e);(0,A.useEffect)(()=>{if(o)return;let a=Date.now(),c=t.map(i=>{if(i.duration===1/0)return;let d=(i.duration||0)+i.pauseDuration-(a-i.createdAt);if(d<0){i.visible&&n.dismiss(i.id);return}return setTimeout(()=>n.dismiss(i.id),d)});return()=>{c.forEach(i=>i&&clearTimeout(i))}},[t,o]);
let s=(0,A.useCallback)(()=>{o&&l({type:6,time:Date.now()})},[o]),r=(0,A.useCallback)((a,c)=>{let{reverseOrder:i=!1,gutter:d=8,defaultPosition:p}=c||{},g=t.filter(m=>(m.position||p)===(a.position||p)&&m.height),z=g.findIndex(m=>m.id===a.id),O=g.filter((m,B)=>B<z&&m.visible).length;return g.filter(m=>m.visible).slice(...i?[O+1]:[0,O]).reduce((m,B)=>m+(B.height||0)+d,0)},[t]);return{toasts:t,handlers:{updateHeight:pe,startPause:de,endPause:s,calculateOffset:r}}};var T=W(require("react")),b=require("goober");var y=W(require("react")),x=require("goober");var h=require("goober"),
me='',le='',w='';var C=require("goober"),Te='',M='';var P=require("goober"),fe='',ye='',F='';var ge='',he='',xe='',be='',U=({toast:e})=>{let{icon:t,type:o,iconTheme:s}=e;return t!==void 0?typeof t=="string"?y.createElement(be,null,t):t:o==="blank"?null:y.createElement(he,null,y.createElement(M,{...s}),o!=="loading"&&y.createElement(ge,null,o==="error"?y.createElement(w,{...s}):y.createElement(F,{...s})))};
var Se=e=>'',Ae=e=>'',Pe="0%{opacity:0;} 100%{opacity:1;}",Oe="0%{opacity:1;} 100%{opacity:0;}",Ee='',Re='',ve=(e,t)=>{let s=e.includes("top")?1:-1,[r,a]=R()?[Pe,Oe]:[Se(s),Ae(s)];return{}},$=T.memo(({toast:e,position:t,style:o,children:s})=>{let r=e.height?ve(e.position||t||"top-center",e.visible):{opacity:0},a=T.createElement(U,{toast:e}),c=T.createElement(Re,{...e.ariaProps},u(e.message,e));return T.createElement(Ee,{className:e.className,style:{...r,...o,...e.style}},typeof s=="function"?s({icon:a,message:c}):T.createElement(T.Fragment,null,a,c))});var N=require("goober"),f=W(require("react"));(0,N.setup)(f.createElement);
var Ie=({id:e,className:t,style:o,onHeightUpdate:s,children:r})=>{let a=f.useCallback(c=>{if(c){let i=()=>{let d=c.getBoundingClientRect().height;s(e,d)};i(),new MutationObserver(i).observe(c,{subtree:!0,childList:!0,characterData:!0})}},[e,s]);return f.createElement("div",{ref:a,className:t,style:o},r)},De=(e,t)=>{let o=e.includes("top"),s=o?{top:0}:{bottom:0},r=e.includes("center")?{justifyContent:"center"}:e.includes("right")?{justifyContent:"flex-end"}:{};
return{left:0,right:0,display:"flex",position:"absolute",transition:R()?void 0:"all 230ms cubic-bezier(.21,1.02,.73,1)",transform:'translateY(5px)',...s,...r}},ke='',L=16,J=({reverseOrder:e,position:t="top-center",toastOptions:o,gutter:s,children:r,containerStyle:a,containerClassName:c})=>{let{toasts:i,handlers:d}=V(o);
return f.createElement("div",{style:{position:"fixed",zIndex:9999,top:L,left:L,right:L,bottom:L,pointerEvents:"none",...a},className:c,onMouseEnter:d.startPause,onMouseLeave:d.endPause},i.map(p=>{let g=p.position||t,z=d.calculateOffset(p,{reverseOrder:e,gutter:s,defaultPosition:t}),O=De(g,z);return f.createElement(Ie,{id:p.id,key:p.id,onHeightUpdate:d.updateHeight,className:p.visible?ke:"",style:O},p.type==="custom"?u(p.message,p):r?r(p):f.createElement($,{toast:p,position:g}))}))};var _e=n;0&&(module.exports={CheckmarkIcon,ErrorIcon,LoaderIcon,ToastBar,ToastIcon,Toaster,resolveValue,toast,useToaster,useToasterStore});
//# sourceMappingURL=index.js.map`,
id,
)

const clientId = normalizePath(
path.join(
process.env.RWJS_CWD,
'web',
'dist',
'rsc',
'assets',
'rsc-index.js-15.mjs',
),
)

// What we are interested in seeing here is:
// - The import of `renderFromRscServer` from `@redwoodjs/vite/client`
// - The call to `renderFromRscServer` for each page that wasn't already imported
expect(output)
.toMatchInlineSnapshot(`"const CLIENT_REFERENCE = Symbol.for('react.client.reference');
export const CheckmarkIcon = Object.defineProperties(function() {throw new Error("Attempted to call CheckmarkIcon() from the server but CheckmarkIcon is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#CheckmarkIcon"}});
export const ErrorIcon = Object.defineProperties(function() {throw new Error("Attempted to call ErrorIcon() from the server but ErrorIcon is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#ErrorIcon"}});
export const LoaderIcon = Object.defineProperties(function() {throw new Error("Attempted to call LoaderIcon() from the server but LoaderIcon is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#LoaderIcon"}});
export const ToastBar = Object.defineProperties(function() {throw new Error("Attempted to call ToastBar() from the server but ToastBar is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#ToastBar"}});
export const ToastIcon = Object.defineProperties(function() {throw new Error("Attempted to call ToastIcon() from the server but ToastIcon is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#ToastIcon"}});
export const Toaster = Object.defineProperties(function() {throw new Error("Attempted to call Toaster() from the server but Toaster is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#Toaster"}});
export const resolveValue = Object.defineProperties(function() {throw new Error("Attempted to call resolveValue() from the server but resolveValue is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#resolveValue"}});
export const toast = Object.defineProperties(function() {throw new Error("Attempted to call toast() from the server but toast is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#toast"}});
export const useToaster = Object.defineProperties(function() {throw new Error("Attempted to call useToaster() from the server but useToaster is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#useToaster"}});
export const useToasterStore = Object.defineProperties(function() {throw new Error("Attempted to call useToasterStore() from the server but useToasterStore is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${clientId}#useToasterStore"}});
"`)
})
})
77 changes: 71 additions & 6 deletions packages/vite/src/plugins/vite-plugin-rsc-transform-client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import path from 'node:path'

import type { Statement, ModuleDeclaration } from 'acorn'
import type { Statement, ModuleDeclaration, AssignmentExpression } from 'acorn'
import * as acorn from 'acorn-loose'
import type { Plugin } from 'vite'
import { normalizePath, type Plugin } from 'vite'

import { getPaths } from '@redwoodjs/project-config'

Expand Down Expand Up @@ -122,7 +122,7 @@ function addExportNames(names: Array<string>, node: any) {
}

/**
* Parses `body` for exports and stores them in `names` (the second argument)
* Parses `body` for exports and stores them in `names`
*/
async function parseExportNamesIntoNames(
code: string,
Expand Down Expand Up @@ -181,6 +181,65 @@ async function parseExportNamesIntoNames(
}

continue

// For CJS support
case 'ExpressionStatement': {
let assignmentExpression: AssignmentExpression | null = null

if (node.expression.type === 'AssignmentExpression') {
assignmentExpression = node.expression
} else if (
node.expression.type === 'LogicalExpression' &&
node.expression.right.type === 'AssignmentExpression'
) {
assignmentExpression = node.expression.right
}

if (!assignmentExpression) {
continue
}

if (assignmentExpression.left.type !== 'MemberExpression') {
continue
}

if (assignmentExpression.left.object.type !== 'Identifier') {
continue
}

if (
assignmentExpression.left.object.name === 'exports' &&
assignmentExpression.left.property.type === 'Identifier'
) {
// This is for handling exports like
// exports.Link = ...

if (!names.includes(assignmentExpression.left.property.name)) {
names.push(assignmentExpression.left.property.name)
}
} else if (
assignmentExpression.left.object.name === 'module' &&
assignmentExpression.left.property.type === 'Identifier' &&
assignmentExpression.left.property.name === 'exports' &&
assignmentExpression.right.type === 'ObjectExpression'
) {
// This is for handling exports like
// module.exports = { Link: ... }

assignmentExpression.right.properties.forEach((property) => {
if (
property.type === 'Property' &&
property.key.type === 'Identifier'
) {
if (!names.includes(property.key.name)) {
names.push(property.key.name)
}
}
})
}

continue
}
}
}
}
Expand All @@ -203,9 +262,15 @@ async function transformClientModule(
([_key, value]) => value === url,
)

const loadId = entryRecord
? path.join(getPaths().web.distRsc, 'assets', `${entryRecord[0]}.mjs`)
: url
console.log('entryRecord', entryRecord)

const loadId = normalizePath(
entryRecord
? path.join(getPaths().web.distRsc, 'assets', `${entryRecord[0]}.mjs`)
: url,
)

console.log('loadId', loadId)

let newSrc =
"const CLIENT_REFERENCE = Symbol.for('react.client.reference');\n"
Expand Down
Loading
Loading