Skip to content

Commit

Permalink
RSC: Support CJS 'use client' modules (redwoodjs#10682)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe committed May 24, 2024
1 parent 6e6c9ec commit e38001b
Show file tree
Hide file tree
Showing 4 changed files with 299 additions and 8 deletions.
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

0 comments on commit e38001b

Please sign in to comment.